KTOR-9451 Support nested generic types#5500
Conversation
📝 WalkthroughWalkthroughAdds KType caching and propagation to JSON schema inference, alters descriptor recursion and title emission for parameterized types, and improves JVM reflection handling by substituting generic type arguments and resolving value-class underlying types relative to owner KType. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
e7592de to
75a8e65
Compare
75a8e65 to
bfd7efa
Compare
bfd7efa to
f7eb788
Compare
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt (1)
242-250:⚠️ Potential issue | 🔴 Critical
resolvedPropertyTypeis computed but never used for the actual schema — defeats the generic substitution.
buildSchemaOrRef(prop.returnType, …)is still passing the unresolvedprop.returnType. For a property likeResponse<T>.datawhosereturnType.classifieris aKTypeParameter,buildSchemaInternalfalls through theclassifier as? KClass<*>cast and returns a bareJsonSchema(type = JsonType.OBJECT)— so the expected nested structure inResponse.yaml(data → items/total → Country) won't be produced through the reflection path. TheisNullablefix alone isn't enough.🐛 Proposed fix
val propertyName = adapter.getName(prop) val resolvedPropertyType = swapTypeArgs(prop.returnType, type) val propertyIsNullable = adapter.isNullable(resolvedPropertyType) - properties[propertyName] = buildSchemaOrRef(prop.returnType, visiting, prop.annotations) + properties[propertyName] = buildSchemaOrRef(resolvedPropertyType, visiting, prop.annotations)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt` around lines 242 - 250, The code calculates resolvedPropertyType via swapTypeArgs but still calls buildSchemaOrRef(prop.returnType…), so generic type substitutions are ignored; change the call to use the substituted type (resolvedPropertyType) and use that same resolved type when checking nullability and when passing annotations/context into buildSchemaInternal (i.e., replace occurrences of prop.returnType with resolvedPropertyType for schema creation), ensuring functions like buildSchemaOrRef, buildSchemaInternal, swapTypeArgs, and adapter.isNullable operate on the resolvedPropertyType so Reflection produces the correct nested schema for generic properties (e.g., Response<T>.data).ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt (1)
77-103:⚠️ Potential issue | 🟡 MinorUpdate ABI signatures after verifying Default getter behavior is intentional.
The implementation creates a fresh
KotlinxSerializerJsonSchemaInferenceinstance on every access toDefault(line 77-78), which differs from a cached singleton. Since each instance has its ownkTypeLookup, there is no cross-call leakage—each top-levelbuildSchemacall works against an independent cache. However, this means:
KotlinxJsonSchemaInference(line 42) andSerialDescriptor.buildJsonSchema(...)(line 349) produce ephemeral instances whosekTypeLookupstays empty, makingisParameterized()effectively a no-op in those paths. If this is intentional, add a brief comment clarifying the scope; if not, consider storing the instance or passingkTypeLookupas state.The key strategy
type.toString().substringBefore('<')(line 94) is fragile—KType.toString()is not a contractually stable identity. While the current code is self-correcting (line 103 only triggers on entries with non-emptyarguments), document the invariant for clarity. Alternatively, use(type.classifier as? KClass<*>)?.qualifiedNamewhere available and fall back totoString()only for web/JS.Run
./gradlew :ktor-openapi-schema:updateLegacyAbiandcheckLegacyAbito update the/api/signatures—this is a new public property (getter) and requires ABI tracking.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt` around lines 77 - 103, The Default getter currently returns a new KotlinxSerializerJsonSchemaInference each call which yields ephemeral kTypeLookup caches (affecting buildSchema, KotlinxJsonSchemaInference and SerialDescriptor.buildJsonSchema paths) — either make Default return a cached singleton instance of KotlinxSerializerJsonSchemaInference (so kTypeLookup is shared) or explicitly document the intended per-call scope with a comment; while here, harden includeKType’s key strategy by using (type.classifier as? KClass<*>)?.qualifiedName ?: type.toString().substringBefore('<') in includeKType and ensure isParameterized reads from the intended shared kTypeLookup; after choosing the approach, run ./gradlew :ktor-openapi-schema:updateLegacyAbi and checkLegacyAbi to update ABI signatures for the new public getter behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 77-103: The Default getter currently returns a new
KotlinxSerializerJsonSchemaInference each call which yields ephemeral
kTypeLookup caches (affecting buildSchema, KotlinxJsonSchemaInference and
SerialDescriptor.buildJsonSchema paths) — either make Default return a cached
singleton instance of KotlinxSerializerJsonSchemaInference (so kTypeLookup is
shared) or explicitly document the intended per-call scope with a comment; while
here, harden includeKType’s key strategy by using (type.classifier as?
KClass<*>)?.qualifiedName ?: type.toString().substringBefore('<') in
includeKType and ensure isParameterized reads from the intended shared
kTypeLookup; after choosing the approach, run ./gradlew
:ktor-openapi-schema:updateLegacyAbi and checkLegacyAbi to update ABI signatures
for the new public getter behavior.
In
`@ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt`:
- Around line 242-250: The code calculates resolvedPropertyType via swapTypeArgs
but still calls buildSchemaOrRef(prop.returnType…), so generic type
substitutions are ignored; change the call to use the substituted type
(resolvedPropertyType) and use that same resolved type when checking nullability
and when passing annotations/context into buildSchemaInternal (i.e., replace
occurrences of prop.returnType with resolvedPropertyType for schema creation),
ensuring functions like buildSchemaOrRef, buildSchemaInternal, swapTypeArgs, and
adapter.isNullable operate on the resolvedPropertyType so Reflection produces
the correct nested schema for generic properties (e.g., Response<T>.data).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d2751bab-a6f0-4f39-ae4d-dfe6e46ba7ac
📒 Files selected for processing (4)
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.ktktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yamlktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt (1)
80-100:⚠️ Potential issue | 🟠 MajorShared
Defaultinstance:kTypeLookupis unsynchronized, unbounded, and drops sibling type args.
KotlinxSerializerJsonSchemaInference.Default(Lines 77-78) is a process-wide singleton, andkTypeLookupis a plainmutableMapOfthat is written frombuildSchema/includeKTypewithout synchronization and never cleared. Three concrete concerns:
- Thread-safety: concurrent
buildSchema(...)calls onDefault(the normal case when used viaKotlinxJsonSchemaInference) race on the same map and can raiseConcurrentModificationExceptionor produce corrupt reads.- Unbounded growth: every distinct type ever inferred stays resident for the lifetime of the process — effectively a leak for long-running servers.
- Sibling-argument collision: keying on
type.toString().substringBefore('<')and early-returning at Line 95 means that for e.g.Pair<List<Int>, List<String>>, the firstList<…>caches"kotlin.collections.List"and the recursion into the secondList<String>is skipped entirely — soString'sKTypeis never registered. This directly undermines the PR's stated goal of "populating a lookup of KTypes so nested generic information is available for schema inference" as soon as the same generic container appears twice with different arguments (and the JVM-sideswapTypeArgsconsults this information).Within this file
isParameterized()only readsarguments.isNotEmpty(), so the local title-suppression still works; the failure mode is for any consumer (including the JVM inference added in this PR) that looks up a specific argument type.💡 Suggested direction
- Scope the lookup to a single
buildSchemacall (e.g., pass it alongsidevisiting) so it is inherently per-invocation and thread-local; or- If a shared cache really is desired, use
ConcurrentHashMap/a synchronized wrapper and key by the fullKType(ortype.toString()) so distinct parameterizations don't collide, and recurse unconditionally into eachtypeArg.typeregardless of whether the parent name was seen before.- private val kTypeLookup = mutableMapOf<String, KType>() - - override fun buildSchema(type: KType): JsonSchema { - includeKType(type) - return buildSchemaFromDescriptor( - module.serializer(type).descriptor, - // parameterized types cannot be referenced from their serial name - includeTitle = type.arguments.isEmpty(), - visiting = mutableSetOf() - ) - } - - private fun includeKType(type: KType) { - // use toString() because qualifiedName is unavailable in web - val qualifiedName = type.toString().substringBefore('<') - if (qualifiedName in kTypeLookup) return - kTypeLookup[qualifiedName] = type - for (typeArg in type.arguments) { - typeArg.type?.let(::includeKType) - } - } + override fun buildSchema(type: KType): JsonSchema { + val kTypeLookup = mutableMapOf<String, KType>() + includeKType(type, kTypeLookup) + return buildSchemaFromDescriptor( + module.serializer(type).descriptor, + // parameterized types cannot be referenced from their serial name + includeTitle = type.arguments.isEmpty(), + visiting = mutableSetOf(), + kTypeLookup = kTypeLookup, + ) + } + + private fun includeKType(type: KType, lookup: MutableMap<String, KType>) { + // use toString() because qualifiedName is unavailable in web + val key = type.toString() + if (lookup.put(key, type) != null) return + for (typeArg in type.arguments) { + typeArg.type?.let { includeKType(it, lookup) } + } + }This also requires threading
kTypeLookupthroughbuildSchemaFromDescriptor/isParameterized()rather than reading a field.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt` around lines 80 - 100, The shared mutable kTypeLookup causes races, leaks, and generic-argument collisions; make the lookup be per-invocation (thread-local) by creating a new map inside buildSchema and passing it through to includeKType and buildSchemaFromDescriptor/ isParameterized (i.e., thread-scope the map alongside visiting) so each buildSchema call uses its own lookup and is cleared when done; ensure includeKType keys by the full type string (type.toString()) and do not early-return skipping recursion into other type arguments — always recurse into each typeArg.type to register distinct parameterizations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 102-103: isParameterized() relies on nonNullSerialName while
kTypeLookup was populated using KType.toString().substringBefore('<'), creating
mismatched keys; fix by normalizing the lookup so both sides use the same key
strategy: either change the lookup population to store mappings by serial
descriptor name (e.g., use SerialDescriptor.serialName.substringBefore('<') or a
normalizedSerialName utility) or change kTypeLookup to map KType →
SerialDescriptor directly, and update usages in isParameterized(), any callers
referencing kTypeLookup, and related logic (nonNullSerialName, includeTitle,
LIST/MAP branches) so keys are consistent and no fragile coupling remains.
---
Outside diff comments:
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`:
- Around line 80-100: The shared mutable kTypeLookup causes races, leaks, and
generic-argument collisions; make the lookup be per-invocation (thread-local) by
creating a new map inside buildSchema and passing it through to includeKType and
buildSchemaFromDescriptor/ isParameterized (i.e., thread-scope the map alongside
visiting) so each buildSchema call uses its own lookup and is cleared when done;
ensure includeKType keys by the full type string (type.toString()) and do not
early-return skipping recursion into other type arguments — always recurse into
each typeArg.type to register distinct parameterizations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b67a59f5-64c0-4c71-8eee-1ffc09a85ead
📒 Files selected for processing (2)
ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt
🚧 Files skipped from review as they are similar to previous changes (1)
- ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt
| private fun SerialDescriptor.isParameterized(): Boolean = | ||
| kTypeLookup[nonNullSerialName]?.arguments?.isNotEmpty() == true |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify serialName vs KType.toString() for built-in containers used via module.serializer(type).descriptor
rg -nP --type=kt -C2 '\bnonNullSerialName\b|isParameterized\b' ktor-shared/ktor-openapi-schema
rg -nP --type=kt -C3 'kTypeLookup' ktor-shared/ktor-openapi-schemaRepository: ktorio/ktor
Length of output: 108
🏁 Script executed:
#!/bin/bash
# Search for nonNullSerialName, isParameterized, and kTypeLookup without -t flag
rg -nP -C2 'nonNullSerialName|isParameterized' ktor-shared/ktor-openapi-schema
echo "---"
rg -nP -C3 'kTypeLookup' ktor-shared/ktor-openapi-schemaRepository: ktorio/ktor
Length of output: 10316
🏁 Script executed:
#!/bin/bash
# Search for all calls to isParameterized
rg -n 'isParameterized' ktor-shared/ktor-openapi-schema
# Check the LIST and MAP handling sections to see if they use includeTitle
sed -n '205,235p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 1670
🏁 Script executed:
#!/bin/bash
# Check the CLASS/OBJECT branch to see how includeTitle is used
sed -n '133,170p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# Search for tests that exercise this code path with List/Map types
find ktor-shared/ktor-openapi-schema -name '*Test.kt' -o -name '*Spec.kt' | head -5
# Look for actual serialNames of ArrayList/HashMap
rg -n 'ArrayList|HashMap' ktor-shared/ktor-openapi-schema --type=kt | head -20Repository: ktorio/ktor
Length of output: 2248
🏁 Script executed:
#!/bin/bash
# Look at test files to see what types are exercised
ls -la ktor-shared/ktor-openapi-schema/*/test/io/ktor/openapi/
# Check one of the test files for List/Map examples
head -100 ktor-shared/ktor-openapi-schema/jvm/test/io/ktor/openapi/GenericElementTest.ktRepository: ktorio/ktor
Length of output: 3503
🏁 Script executed:
#!/bin/bash
# Check the reflection test which likely exercises List/Map types
head -150 ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/KotlinxJsonSchemaInferenceTest.kt
# Also search for List and Map in test files
rg -n 'List<|Map<' ktor-shared/ktor-openapi-schema --glob='*Test.kt' | head -20Repository: ktorio/ktor
Length of output: 1872
🏁 Script executed:
#!/bin/bash
# Look at the AbstractSchemaInferenceTest to see what List/Map tests are doing
sed -n '70,100p' ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt
# Also check how the buildSchema method handles these types
sed -n '82,100p' ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.ktRepository: ktorio/ktor
Length of output: 2019
🏁 Script executed:
#!/bin/bash
# Let's trace what happens when we get the descriptor for List<Address>
# First, check if there's any code that shows what descriptor.kind returns for ArrayList/HashMap
# Search for how descriptors are obtained or what their serialNames might be
rg -n 'serializer.*List|serializer.*Map' ktor-shared/ktor-openapi-schema --glob='*.kt' | head -10
# Check if there are any direct descriptor references
rg -n 'ArrayList|HashMap|descriptor\.kind' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 919
🏁 Script executed:
#!/bin/bash
# Search for buildSchemaOrReference implementation
rg -n 'fun buildSchemaOrReference' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt -A 10Repository: ktorio/ktor
Length of output: 520
🏁 Script executed:
#!/bin/bash
# Find the full buildSchema implementation
sed -n '82,92p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# And check buildSchemaOrReference complete implementation
sed -n '298,320p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 1230
🏁 Script executed:
#!/bin/bash
# Let's verify: what is the actual key used for List types when includeKType is called?
# And understand the full picture
# Get the full file context around buildSchema and includeKType
sed -n '82,130p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# Also check if isParameterized is called anywhere else besides line 108
rg -n 'isParameterized\(\)' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 2181
🏁 Script executed:
#!/bin/bash
# Now let's verify the actual serialNames by checking what happens with List/Map in real cases
# Search for where descriptors for built-in types might come from
# Check the isContainerType function to understand how List/Map are handled
rg -n 'isContainerType' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt -A 5Repository: ktorio/ktor
Length of output: 540
🏁 Script executed:
#!/bin/bash
# Let's understand: when we have List<Address>, what is the descriptor's serialName?
# Create a simple test to check this
python3 << 'EOF'
# Looking at the logic:
# 1. buildSchema(List<Address>) calls:
# - includeKType(List<Address>) → stores key "kotlin.collections.List" in kTypeLookup
# - module.serializer(List<Address>).descriptor → gets descriptor with kind == StructureKind.LIST
# - The descriptor's serialName for ArrayList would be "kotlin.collections.ArrayList"
#
# 2. In buildSchemaFromDescriptor, when it hits the LIST branch:
# - It calls buildSchemaOrReference for item descriptor without explicit includeTitle
# - So includeTitle defaults to !descriptor.isParameterized()
# - isParameterized() looks up nonNullSerialName in kTypeLookup
# - But if the item descriptor is also a container (e.g., List<String>),
# its nonNullSerialName would be "kotlin.collections.ArrayList" or similar
# - But kTypeLookup has "kotlin.collections.List"
# - Lookup fails, returns false
#
# The claim is that this is a key mismatch that will cause wrong behavior
# Let me verify if buildSchemaOrReference can be called with container descriptors
print("Analysis:")
print("1. buildSchema() stores type keys as KType.toString().substringBefore('<')")
print(" Example: List<Address> → 'kotlin.collections.List'")
print("")
print("2. isParameterized() looks up using descriptor.nonNullSerialName")
print(" For ArrayList descriptor → 'kotlin.collections.ArrayList'")
print("")
print("3. These keys don't match → lookup fails")
print("")
print("4. buildSchemaOrReference calls buildSchemaFromDescriptor without includeTitle param")
print(" So default is used: !descriptor.isParameterized()")
print("")
print("5. For LIST/MAP descriptors, includeTitle is ignored anyway")
print("6. But for CLASS/OBJECT descriptors passed as element type, it would matter")
EOFRepository: ktorio/ktor
Length of output: 638
🏁 Script executed:
#!/bin/bash
# Let's verify: can isParameterized() actually affect real code paths?
# Search for buildSchemaOrReference calls to see what descriptors are passed
sed -n '298,320p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# And check the signature of buildSchemaOrReference - does it ever pass includeTitle explicitly?
rg -n 'buildSchemaOrReference' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# Check if buildSchemaOrReference can receive container descriptors
sed -n '140,160p' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 2061
🏁 Script executed:
#!/bin/bash
# Final verification: check if isParameterized is only used in the default parameter
# or if there are other calls to it that might be affected
rg -n 'isParameterized' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt
# Also verify: what are the actual serialNames for ArrayList and HashMap?
# by checking kotlinx-serialization built-in descriptors
cat > /tmp/test_serialnames.kt << 'KOTLIN'
import kotlinx.serialization.serializer
import kotlin.reflect.typeOf
fun main() {
val listType = typeOf<List<String>>()
val mapType = typeOf<Map<String, String>>()
val listDescriptor = serializer(listType).descriptor
val mapDescriptor = serializer(mapType).descriptor
println("List descriptor serialName: ${listDescriptor.serialName}")
println("List descriptor kind: ${listDescriptor.kind}")
println("Map descriptor serialName: ${mapDescriptor.serialName}")
println("Map descriptor kind: ${mapDescriptor.kind}")
}
KOTLIN
# Check if we can find any hint in the code about what these serialNames are
rg -n 'kotlin.collections' ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.ktRepository: ktorio/ktor
Length of output: 187
isParameterized() uses mismatched lookup keys for built-in container descriptors, creating fragile coupling.
isParameterized() looks up descriptors by nonNullSerialName (e.g., "kotlin.collections.ArrayList"), but kTypeLookup stores built-in types by their KType.toString().substringBefore('<') keys (e.g., "kotlin.collections.List"). While this currently has minimal impact—includeTitle only affects CLASS/OBJECT kinds and LIST/MAP branches ignore it—the mismatch creates fragile coupling. Future callers trusting isParameterized() on element descriptors or changes to includeTitle usage could yield incorrect results.
Recommend either documenting the "class/object serial names only" contract explicitly in code comments, or normalizing both sides by storing KType → SerialDescriptor mappings directly instead of deriving keys separately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt`
around lines 102 - 103, isParameterized() relies on nonNullSerialName while
kTypeLookup was populated using KType.toString().substringBefore('<'), creating
mismatched keys; fix by normalizing the lookup so both sides use the same key
strategy: either change the lookup population to store mappings by serial
descriptor name (e.g., use SerialDescriptor.serialName.substringBefore('<') or a
normalizedSerialName utility) or change kTypeLookup to map KType →
SerialDescriptor directly, and update usages in isParameterized(), any callers
referencing kTypeLookup, and related logic (nonNullSerialName, includeTitle,
LIST/MAP branches) so keys are consistent and no fragile coupling remains.
Subsystem
Server, OpenAPI (Schema)
Motivation
KTOR-9451 OpenAPI schema inference not working for custom nested generics
Solution
We were previously only checking if the supplied
KTypewas parameterized, but this logic doesn't work for multiple levels of generics. To account for this, we need to initially traverse the type argument tree to populate a lookup of KTypes, because the generated serializers' descriptors have no way of introspecting on this.Additionally, I noticed that generics were only supported for collection types on the reflection schema inference side, so I also fixed this.